iT邦幫忙

2024 iThome 鐵人賽

DAY 29
0
Mobile Development

從零開始以Flutter打造跨平台聊天APP系列 第 29

Day-29 實作(10) 將 Flutter 串接 API 到後端伺服器

  • 分享至 

  • xImage
  •  

接下來我們試著將 Flutter 專案與後端伺服器聯絡

登入登出

首先登入的部分,實作時發現 InheritedWidget 不太夠用,因此又加入了 ChangeNotifier 幫忙管理狀態。

class MeStateNotifier with ChangeNotifier {
  Me? _me;
  Me? get me => _me;
  set me(Me? me) {
    _me = me;
    notifyListeners();
  }
}

並將原本的 MeDataLayer 稍作修改

class MeDataLayer extends InheritedWidget {
  const MeDataLayer({super.key, required this.meState, required super.child});

  final MeStateNotifier meState;

  static MeDataLayer of(BuildContext context) {
    final res = context.dependOnInheritedWidgetOfExactType<MeDataLayer>();
    assert(res != null, 'No UserData found in context');
    return res!;
  }

  @override
  bool updateShouldNotify(MeDataLayer oldWidget) {
    return oldWidget.meState.me != meState.me;
  }
}

接著我們處理 http 的部分。

登入時會將帳號與密碼交給後端進行確認,如果符合,就將 token 儲存在 SharedPreferences 中。登出時則向後端請求清除 token,並且將裝置的快取也做刪除。

class Me {
  // ...
  static Future<Me> login(String user, String password) async {
    var res = await http.post(
        Uri(host: "localhost", port: 8081, path: "/api/v1/auth/login"),
        headers: {"Content-Type": "application/json"},
        body:
            jsonEncode(<String, dynamic>{"user": user, "password": password}));
    if (res.statusCode != 200) {
      throw (res);
    }
    Map content = jsonDecode(res.body);
    String token = content['token'];

    res = await http.get(Uri(host: "localhost", port: 8081, path: "/api/v1/me"),
        headers: {"Authorization": token});

    if (res.statusCode != 200) {
      throw (res);
    }

    var pref = await SharedPreferences.getInstance();
    pref.setString("token", token);

    content = jsonDecode(res.body);

    return Me(
        uid: content["uid"],
        id: content["user"],
        name: content["name"],
        profile: content["profile"] == "" ? null : content["profile"],
        publicKey: content["public_key"]);
  }

  static Future<void> logout() async {
    var pref = await SharedPreferences.getInstance();
    String? token = pref.getString("token");
    if (token == null) return;

    var res = await http.post(
        Uri(host: "localhost", port: 8081, path: "/api/v1/auth/logout"),
        headers: {"Authorization": token});
    if (res.statusCode != 204) {
      throw ('unexpected error');
    }
    await pref.remove("token");
  }
}

而在一開始決定是否登入的畫面則依據 SharedPreferences 是否有值來決定

class _RoutingLoginOrMeState extends State<RoutingLoginOrMe> {
  late Future<(bool, Me?)> _checkKeepLogin;

  @override
  void initState() {
    super.initState();
    _checkKeepLogin = check();
  }

  Future<(bool, Me?)> check() async {
    var p = await SharedPreferences.getInstance();
    var token = p.getString("token");
    if (token == null) {
      return (false, null);
    }

    var res = await http.get(
        Uri(host: "localhost", port: 8081, path: "/api/v1/me"),
        headers: {"Authorization": token});

    if (res.statusCode != 200) {
      throw (res);
    }

    var content = jsonDecode(res.body);

    return (
      true,
      Me(
          uid: content["uid"],
          id: content["user"],
          name: content["name"],
          profile: content["profile"] == "" ? null : content["profile"],
          publicKey: content["public_key"])
    );
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
        future: _checkKeepLogin,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Scaffold(body: MyErrorWidget(snapshot.error.toString()));
          } else if (snapshot.hasData) {
            if (snapshot.data!.$1 == false) {
              return const LoginPage();
            }
            MeDataLayer.of(context).meState.me = snapshot.data!.$2;
            return const MePage();
          }
          return const Scaffold(
              body: Center(
            child: CircularProgressIndicator(),
          ));
        });
  }
}

當然,這會有個問題,如果 SharedPreferences 中的 token 有問題,就會一直卡在錯誤畫面,因此我們需要在錯誤發生時也將 SharedPreferences 清空。

if (snapshot.hasError) {
  Me.logout();
  MeDataLayer.of(context).meState.me = null;
  return Scaffold(body: MyErrorWidget(snapshot.error.toString()));
}

朋友列表

接著我們可以處理朋友列表的部分,這裡的實作我們改在 SliverListbuilder 實現下滑載入更多。當 index 等於目前 list 長度少一時,我們改呼叫 _load() 。由於最一開始是沒有長度的,因此我們在 initState() 即可先呼叫一次 _load() 並且我們預設一開始都是有資料可以載入的,直到無資料載入時我們再將 _loadedEnd 設為 false

class _HomePageState extends State<HomePage> {
  final FriendListStateNotifier _friendListState = FriendListStateNotifier();
  final _scrollCtrl = ScrollController();
  bool _loadedEnd = false;
  int from = 0;

  @override
  void initState() {
    _load();
    super.initState();
  }

  @override
  void dispose() {
    super.dispose();
    _scrollCtrl.dispose();
  }

  Future<void> _load() async {
    if (_loadedEnd) {
      return;
    }

    var (loadedList, next) = await Friend.load(from);
    from = next;

    if (loadedList != null) {
      _friendListState.addAll(loadedList);
    } else {
      if (mounted) {
        setState(() {
          _loadedEnd = true;
        });
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    Me me = MeDataLayer.of(context).meState.me!;
    return ListenableBuilder(
        listenable: _friendListState,
        builder: (BuildContext context, Widget? child) {
          return Scrollbar(
            controller: _scrollCtrl,
            child: CustomScrollView(
              controller: _scrollCtrl,
              slivers: <Widget>[
                SliverToBoxAdapter(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      const SizedBox(height: 10),
                      ListTile(
                        leading: me.profile == null
                            ? Image.asset("assets/default_profile.png")
                            : Image.network(me.profile!),
                        title: Text(
                          me.name,
                          style: const TextStyle(fontWeight: FontWeight.bold),
                        ),
                      ),
                      const Padding(
                        padding: EdgeInsets.symmetric(
                            horizontal: 16.0, vertical: 10),
                        child: Text("朋友",
                            style: TextStyle(
                              fontSize: 18,
                            )),
                      )
                    ],
                  ),
                ),
                SliverList(
                  delegate: SliverChildBuilderDelegate(
                    (context, index) {
                      if (index == _friendListState.list.length - 1) {
                        _load();
                      }
                      return FriendCard(
                        friend: _friendListState.list[index],
                      );
                    },
                    childCount: _friendListState.list.length,
                  ),
                ),
                SliverToBoxAdapter(
                    child: Center(
                  child: !_loadedEnd
                      ? const CircularProgressIndicator()
                      : const SizedBox(),
                )),
              ],
            ),
          );
        });
  }
}

這裡也附上呼叫後端所使用的程式碼:

class Friend {
  Friend(
      {required this.profile,
      required this.userName,
      required this.userID,
      required this.channelID});
  String? profile;
  String userName;
  int userID;
  int channelID;

  static Future<(List<Friend>?, int)> load(int from) async {
    var pref = await SharedPreferences.getInstance();
    var token = pref.getString("token");
    if (token == null) {
      return (null, -1);
    }

    var res = await http.get(
        Uri(host: "localhost", port: 8081, path: "/api/v1/friends/$from"),
        headers: {
          'Authorization': token,
        });
    if (res.statusCode != 200) {
      throw (res);
    }
    var data = jsonDecode(res.body);

    var list = data["list"] as List?;
    if (list == null) {
      return (null, -1);
    }
    return (
      List.generate(list.length, (int i) {
        return Friend(
            profile: list[i]["profile"] != "" ? list[i]["profile"] : null,
            userName: list[i]["name"],
            userID: list[i]["uid"],
            channelID: list[i]["channel_id"]);
      }),
      data["next"] as int
    );
  }
}

持續更新中...


上一篇
Day-28 實作(9) 使用 Gin 建立頻道、通知系統
下一篇
Day-30 (佈署應用程式) 心得
系列文
從零開始以Flutter打造跨平台聊天APP30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言